Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\xtask\src\social_preview.rs
Line
Count
Source
1
//! Social preview image generation.
2
//!
3
//! Orchestrates `docker run` against the pinned Playwright image to render
4
//! `templates/social-preview.html` into a 1280x640 PNG with live data
5
//! fetched from the GitHub API. The Rust side is a thin shell: all HTTP,
6
//! template substitution, and screenshotting live in
7
//! `xtask/social-preview/generate.mjs`, which runs inside the container.
8
//!
9
//! The host only needs Rust, Cargo, and Docker. No host-side Node.js, npm,
10
//! or Playwright installation is required.
11
12
use std::path::{Path, PathBuf};
13
14
use anyhow::{bail, Context, Result};
15
16
/// Pinned Playwright Docker image tag.
17
///
18
/// The numeric portion (e.g. `v1.59.1`) must match `@playwright/test` in
19
/// `xtask/social-preview/package.json`. Playwright refuses to run when
20
/// these versions diverge, so bump both in the same commit. See
21
/// `xtask/social-preview/README.md` for details.
22
const PLAYWRIGHT_IMAGE: &str = "mcr.microsoft.com/playwright:v1.59.1-noble";
23
24
/// Default output path for the generated PNG, relative to the workspace
25
/// root. Lives under `target/` so it shares Cargo's build-artifact
26
/// directory and inherits its `.gitignore` entry.
27
const DEFAULT_OUT: &str = "target/social-preview/social-preview.png";
28
29
/// Container-side mount point for the workspace.
30
const CONTAINER_WORKSPACE: &str = "/workspace";
31
32
/// All side-effecting operations required by this module.
33
///
34
/// Implement with mocks in tests to achieve zero docker, filesystem,
35
/// process, and network side-effects.
36
pub trait SocialPreviewSystem {
37
    /// Return the absolute path to the workspace root (parent of `xtask/`).
38
    ///
39
    /// # Errors
40
    ///
41
    /// Returns an error if the workspace root cannot be resolved.
42
    fn workspace_root(&self) -> Result<PathBuf>;
43
44
    /// Read an environment variable, returning `None` when unset or empty.
45
    fn env_var(&self, key: &str) -> Option<String>;
46
47
    /// Ensure the parent directory of `path` exists, creating it (and any
48
    /// missing ancestors) if necessary.
49
    ///
50
    /// # Arguments
51
    ///
52
    /// * `path` - File path whose parent directory must exist.
53
    ///
54
    /// # Errors
55
    ///
56
    /// Returns an error if the directory cannot be created.
57
    fn ensure_parent_dir(&self, path: &Path) -> Result<()>;
58
59
    /// Verify that `docker` is installed on `PATH` and that its daemon is
60
    /// reachable. Called before any `docker run` invocation so the user
61
    /// gets a helpful message instead of a raw pipe/socket error.
62
    ///
63
    /// # Errors
64
    ///
65
    /// Returns an error describing whether the binary is missing or the
66
    /// daemon is not running.
67
    fn check_docker_ready(&self) -> Result<()>;
68
69
    /// Return `true` when `image` is already present in the local image
70
    /// cache (i.e. `docker image inspect <image>` succeeds).
71
    fn docker_image_exists(&self, image: &str) -> bool;
72
73
    /// Run `docker pull <image>` with inherited stdio so the user sees
74
    /// layer-download progress.
75
    ///
76
    /// # Errors
77
    ///
78
    /// Returns an error if `docker pull` exits with a non-zero status.
79
    fn docker_pull(&self, image: &str) -> Result<()>;
80
81
    /// Invoke `docker` with the given argument list and environment.
82
    ///
83
    /// # Arguments
84
    ///
85
    /// * `args` - Arguments passed to `docker` (starting with the
86
    ///   subcommand, e.g. `run`).
87
    /// * `envs` - Additional `(key, value)` environment variables applied
88
    ///   to the spawned `docker` process; these are forwarded to the
89
    ///   container via explicit `-e` flags built into `args`.
90
    ///
91
    /// # Errors
92
    ///
93
    /// Returns an error if the process cannot be started or exits with a
94
    /// non-zero status.
95
    fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()>;
96
}
97
98
/// Production implementation of [`SocialPreviewSystem`].
99
pub struct RealSystem;
100
101
#[cfg_attr(coverage_nightly, coverage(off))]
102
impl SocialPreviewSystem for RealSystem {
103
    fn workspace_root(&self) -> Result<PathBuf> {
104
        // CARGO_MANIFEST_DIR is set by Cargo when building this binary; it
105
        // points at xtask/, whose parent is the workspace root.
106
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
107
        let root = Path::new(manifest_dir)
108
            .parent()
109
            .context("failed to resolve workspace root from CARGO_MANIFEST_DIR")?
110
            .to_path_buf();
111
        Ok(root)
112
    }
113
114
    fn env_var(&self, key: &str) -> Option<String> {
115
        std::env::var(key).ok().filter(|v| !v.is_empty())
116
    }
117
118
    fn ensure_parent_dir(&self, path: &Path) -> Result<()> {
119
        if let Some(parent) = path.parent() {
120
            std::fs::create_dir_all(parent)
121
                .with_context(|| format!("failed to create directory {}", parent.display()))?;
122
        }
123
        Ok(())
124
    }
125
126
    fn check_docker_ready(&self) -> Result<()> {
127
        // `docker info` is cheap and exercises both the CLI resolution
128
        // path and a round-trip to the daemon socket.
129
        let output = match std::process::Command::new("docker")
130
            .args(["info", "--format", "{{.ServerVersion}}"])
131
            .output()
132
        {
133
            Ok(o) => o,
134
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
135
                bail!(
136
                    "`docker` was not found on PATH. Install Docker Desktop (or the Docker Engine) and ensure `docker` is on your PATH."
137
                );
138
            }
139
            Err(e) => {
140
                return Err(e).context("failed to spawn `docker info`");
141
            }
142
        };
143
        if output.status.success() && !output.stdout.is_empty() {
144
            return Ok(());
145
        }
146
        let stderr = String::from_utf8_lossy(&output.stderr);
147
        bail!(
148
            "Docker is installed but its daemon is not reachable. Start Docker Desktop (or your Docker daemon) and try again.\n  docker info stderr: {}",
149
            stderr.trim()
150
        );
151
    }
152
153
    fn docker_image_exists(&self, image: &str) -> bool {
154
        std::process::Command::new("docker")
155
            .args(["image", "inspect", image])
156
            .stdout(std::process::Stdio::null())
157
            .stderr(std::process::Stdio::null())
158
            .status()
159
            .map(|s| s.success())
160
            .unwrap_or(false)
161
    }
162
163
    fn docker_pull(&self, image: &str) -> Result<()> {
164
        let status = std::process::Command::new("docker")
165
            .args(["pull", image])
166
            .status()
167
            .with_context(|| format!("failed to spawn `docker pull {image}`"))?;
168
        if !status.success() {
169
            bail!("`docker pull {image}` failed with status {status}");
170
        }
171
        Ok(())
172
    }
173
174
    fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()> {
175
        let mut command = std::process::Command::new("docker");
176
        command.args(args);
177
        for (key, value) in envs {
178
            command.env(key, value);
179
        }
180
        let status = command
181
            .status()
182
            .context("failed to spawn `docker`; is Docker installed and on PATH?")?;
183
        if !status.success() {
184
            bail!("`docker {}` failed with status {status}", args.join(" "));
185
        }
186
        Ok(())
187
    }
188
}
189
190
/// Split the caller-supplied `--out` into (host-absolute path, workspace-
191
/// relative path with forward slashes).
192
///
193
/// Accepts any path. Relative paths resolve against the workspace root;
194
/// absolute paths are used as-is. Lexical `..` components are normalised
195
/// so inputs like `sub/../preview.png` are supported. The final resolved
196
/// path must still live under `workspace_root` so the container bind mount
197
/// can reach it at `/workspace/<rel>`; paths outside the workspace are
198
/// rejected with a clear error.
199
11
fn resolve_out_paths(workspace_root: &Path, out: Option<PathBuf>) -> Result<(PathBuf, String)> {
200
11
    let raw = out.unwrap_or_else(|| 
PathBuf::from7
(DEFAULT_OUT));
201
11
    let joined = if raw.is_absolute() {
202
1
        raw.clone()
203
    } else {
204
10
        workspace_root.join(&raw)
205
    };
206
11
    let normalised = normalise_path(&joined);
207
11
    let 
rel9
= normalised.strip_prefix(workspace_root).map_err(|_|
{2
208
2
        anyhow::anyhow!(
209
            "--out must resolve to a path inside the workspace root ({}); got {}",
210
2
            workspace_root.display(),
211
2
            raw.display()
212
        )
213
2
    })?;
214
9
    let rel_str = rel.to_string_lossy().replace('\\', "/");
215
9
    Ok((normalised.clone(), rel_str))
216
11
}
217
218
/// Lexically normalise a path by collapsing `.` and `..` components
219
/// without touching the filesystem. Behaves like `Path::canonicalize`
220
/// minus the requirement that the path exist. `..` at the root is
221
/// dropped (matching POSIX semantics).
222
11
fn normalise_path(path: &Path) -> PathBuf {
223
    use std::path::Component;
224
11
    let mut out = PathBuf::new();
225
45
    for comp in 
path11
.
components11
() {
226
45
        match comp {
227
            Component::ParentDir => {
228
                // Only pop if the last pushed component is a regular
229
                // segment; otherwise drop (root `..`) or keep (leading
230
                // `..` on a relative path).
231
2
                let popped = match out.components().next_back() {
232
                    Some(Component::Normal(_)) => {
233
2
                        out.pop();
234
2
                        true
235
                    }
236
0
                    _ => false,
237
                };
238
2
                if !popped && 
!path.is_absolute()0
{
239
0
                    out.push("..");
240
2
                }
241
            }
242
0
            Component::CurDir => {}
243
43
            other => out.push(other.as_os_str()),
244
        }
245
    }
246
11
    out
247
11
}
248
249
/// Render a `docker` argument list as a single shell-quoted string, purely
250
/// for diagnostic logging. Arguments containing whitespace or shell
251
/// metacharacters are wrapped in single quotes; inner single quotes are
252
/// escaped as `'\''`. This is never re-parsed - it's only printed to
253
/// stdout so a user can copy-paste the exact invocation.
254
8
fn shell_quote_args(args: &[String]) -> String {
255
8
    args.iter()
256
100
        .
map8
(|a| {
257
100
            if a.is_empty()
258
1.16k
                || 
a.chars()100
.
any100
(|c| {
259
1.16k
                    c.is_whitespace()
260
1.15k
                        || matches!(
261
1.16k
                            c,
262
                            '\'' | '"'
263
                                | '$'
264
                                | '`'
265
                                | '\\'
266
                                | '&'
267
                                | '|'
268
                                | ';'
269
                                | '<'
270
                                | '>'
271
                                | '('
272
                                | ')'
273
                                | '{'
274
                                | '}'
275
                                | '*'
276
                                | '?'
277
                                | '#'
278
                                | '!'
279
                                | '['
280
                                | ']'
281
                        )
282
1.16k
                })
283
            {
284
8
                format!("'{}'", a.replace('\'', "'\\''"))
285
            } else {
286
92
                a.clone()
287
            }
288
100
        })
289
8
        .collect::<Vec<_>>()
290
8
        .join(" ")
291
8
}
292
293
/// Build the `docker run` argument list for the generator script.
294
8
fn build_docker_args(workspace_root: &Path, container_out: &str, has_token: bool) -> Vec<String> {
295
8
    let mount = format!(
296
        "{}:{CONTAINER_WORKSPACE}",
297
8
        workspace_root.to_string_lossy().replace('\\', "/")
298
    );
299
8
    let mut args: Vec<String> = vec![
300
8
        "run".into(),
301
8
        "--rm".into(),
302
8
        "-v".into(),
303
8
        mount,
304
8
        "-w".into(),
305
8
        CONTAINER_WORKSPACE.into(),
306
8
        "-e".into(),
307
8
        format!("OUT_PATH={container_out}"),
308
    ];
309
8
    if has_token {
310
2
        args.push("-e".into());
311
2
        args.push("GITHUB_TOKEN".into());
312
6
    }
313
8
    args.push(PLAYWRIGHT_IMAGE.into());
314
8
    args.push("sh".into());
315
8
    args.push("-c".into());
316
    // Install node_modules on first run, then invoke the generator. We
317
    // use `npm ci` (not `npm install`) so the install is strictly driven
318
    // by the committed `package-lock.json`; this keeps runs reproducible
319
    // and prevents the bind-mounted workspace from picking up lockfile
320
    // mutations. Subsequent runs skip the install entirely and stay
321
    // offline.
322
    //
323
    // The install runs in a subshell so it does not alter the CWD of the
324
    // subsequent `node` invocation. `generate.mjs` resolves its inputs
325
    // (template, logo, font, linguist colors) as workspace-relative paths,
326
    // so it must run from `/workspace` - not from
327
    // `/workspace/xtask/social-preview`.
328
8
    args.push(
329
8
        "( cd xtask/social-preview && { [ -d node_modules ] || npm ci; } ) && node xtask/social-preview/generate.mjs"
330
8
            .into(),
331
    );
332
8
    args
333
8
}
334
335
/// Generate the social preview PNG.
336
///
337
/// Resolves the output path, ensures the host-side output directory
338
/// exists, and invokes the Playwright Docker container which runs
339
/// `xtask/social-preview/generate.mjs` to fetch live GitHub data and
340
/// render `templates/social-preview.html` to PNG.
341
///
342
/// # Arguments
343
///
344
/// * `system` - Injected I/O provider.
345
/// * `out` - Optional output path override. Relative paths resolve against
346
///   the workspace root.
347
/// * `token` - Optional GitHub token override. Falls back to the
348
///   `GITHUB_TOKEN` environment variable, then unauthenticated access.
349
///
350
/// # Errors
351
///
352
/// Returns an error if the workspace root cannot be resolved, the output
353
/// directory cannot be created, or the `docker run` invocation fails.
354
11
pub fn generate_social_preview<S: SocialPreviewSystem>(
355
11
    system: &S,
356
11
    out: Option<PathBuf>,
357
11
    token: Option<String>,
358
11
) -> Result<()> {
359
11
    let workspace_root = system.workspace_root()
?0
;
360
11
    let (
host_out9
,
relative_out9
) = resolve_out_paths(&workspace_root, out)
?2
;
361
9
    log::info!("Generating social preview -> {}", host_out.display());
362
9
    system.check_docker_ready()
?1
;
363
8
    if !system.docker_image_exists(PLAYWRIGHT_IMAGE) {
364
1
        log::info!("Pulling Playwright image {PLAYWRIGHT_IMAGE} (first run only)");
365
1
        system.docker_pull(PLAYWRIGHT_IMAGE)
?0
;
366
7
    }
367
8
    system.ensure_parent_dir(&host_out)
?0
;
368
369
8
    let container_out = format!("{CONTAINER_WORKSPACE}/{relative_out}");
370
8
    let resolved_token = token.or_else(|| 
system7
.
env_var7
(
"GITHUB_TOKEN"7
));
371
8
    let has_token = resolved_token.is_some();
372
373
8
    let args = build_docker_args(&workspace_root, &container_out, has_token);
374
8
    let envs: Vec<(String, String)> = resolved_token
375
8
        .into_iter()
376
8
        .map(|t| (
"GITHUB_TOKEN"2
.
to_owned2
(),
t2
))
377
8
        .collect();
378
379
8
    log::info!("Starting Playwright container {PLAYWRIGHT_IMAGE}");
380
8
    log::debug!("+ docker {}", shell_quote_args(&args));
381
8
    system.run_docker(&args, &envs)
?1
;
382
7
    log::info!("Wrote {}", host_out.display());
383
7
    Ok(())
384
11
}
385
386
#[cfg(test)]
387
#[path = "tests/test_social_preview.rs"]
388
mod tests;